;;; guile-openai --- An OpenAI API client for Guile
;;; Copyright © 2023 Andrew Whatson <whatson@tailcall.au>
;;;
;;; This file is part of guile-openai.
;;;
;;; guile-openai is free software: you can redistribute it and/or modify
;;; it under the terms of the GNU Affero General Public License as
;;; published by the Free Software Foundation, either version 3 of the
;;; License, or (at your option) any later version.
;;;
;;; guile-openai is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;;; Affero General Public License for more details.
;;;
;;; You should have received a copy of the GNU Affero General Public
;;; License along with guile-openai.  If not, see
;;; <https://www.gnu.org/licenses/>.

(define-module (openai client)
  #:use-module (openai debug)
  #:use-module (openai utils event-stream)
  #:use-module (openai utils multipart)
  #:use-module (openai utils uri)
  #:use-module (ice-9 match)
  #:use-module (ice-9 textual-ports)
  #:use-module (json record)
  #:use-module (srfi srfi-8)
  #:use-module (srfi srfi-71)
  #:use-module (web client)
  #:use-module (web request)
  #:use-module (web response)
  #:export (openai-base-uri
            openai-api-key
            openai-organization
            openai-default-headers
            openai-default-user

            openai-request
            openai-get
            openai-post-json
            openai-post-multipart))

(define-once openai-base-uri
  (make-parameter "https://api.openai.com/" ->uri))

(define-once openai-api-key
  (make-parameter *unspecified*
                  (lambda (value)
                    (unless (or (unspecified? value) (string? value))
                      (error "openai-api-key must be a string:" value))
                    value)))

(define-once openai-organization
  (make-parameter *unspecified*
                  (lambda (value)
                    (unless (or (unspecified? value) (string? value))
                      (error "openai-organization must be a string:" value))
                    value)))

(define-once openai-default-headers
  (make-parameter '() (lambda (value)
                        (unless (or (null? value) (pair? value))
                          (error "openai-default-headers must be a list:" value))
                        value)))

(define-once openai-default-user
  (make-parameter *unspecified*
                  (lambda (value)
                    (unless (or (unspecified? value) (string? value))
                      (error "openai-default-user must be a string:" value))
                    value)))

(define-json-type <api-response>
  (error "error" <api-error>))

(define-json-type <api-error>
  (message)
  (type)
  (param)
  (code))

(define (openai-request-uri path)
  (apply resolve-uri-refs
         (openai-base-uri)
         (if (pair? path) path (list path))))

(define (openai-request-headers keep-alive? headers)
  (let ((api-key (openai-api-key))
        (organization (openai-organization)))
    (append (openai-default-headers)
            (if keep-alive?
                '()
                '((connection close)))
            (if (unspecified? api-key)
                '()
                `((Authorization . ,(string-append "Bearer " api-key))))
            (if (unspecified? organization)
                '()
                `((OpenAI-Organization . ,organization)))
            headers)))

(define* (openai-request path #:key
                         (body #f)
                         (verify-certificate? #t)
                         (port #f)
                         (method 'GET)
                         (version '(1 . 1))
                         (keep-alive? #f)
                         (headers '()))
  (openai-last-api-call #f)
  ;; XXX: The web module doesn't provide a nice way to capture a fully
  ;; inflated request object (which we want for debugging), so we end up
  ;; jumping through a few hoops here.
  (let* ((uri (openai-request-uri path))
         (port (or port (open-socket-for-uri uri
                                             #:verify-certificate?
                                             verify-certificate?)))
         (bytes (and body (multipart-tree->bytevector body)))
         (headers (openai-request-headers keep-alive? headers))
         (request (build-request uri
                                 #:method method
                                 #:version version
                                 #:headers headers
                                 #:port port))
         (request bytes ((@@ (web client) sanitize-request) request bytes)))
    (openai-last-api-call (list request body #f #f))
    (let ((response (http-request uri
                                  #:body bytes
                                  #:verify-certificate? verify-certificate?
                                  #:port port
                                  #:method method
                                  #:version version
                                  #:keep-alive? keep-alive?
                                  #:headers headers
                                  #:decode-body? #f
                                  #:streaming? #t
                                  #:request request)))
      (openai-last-api-call (list request body response #f))
      (receive (port->data data->result)
          (match (response-content-type response)
            ((or '(application/json)
                 '(application/json (charset . "utf-8")))
             (values get-string-all identity))
            ('(text/event-stream)
             (values port->line-stream line-stream->event-stream)))
        (let ((data (port->data (response-port response))))
          (openai-last-api-call (list request body response data))
          (when (not (= (response-code response) 200))
            (error "Received API error:"
                   (or (false-if-exception (api-error-message
                                            (api-response-error
                                             (json->api-response data))))
                       data)))
          (data->result data))))))

(define* (openai-get path #:rest args)
  (apply openai-request path `(,@args #:method GET)))

(define* (openai-post-json path body #:rest args)
  (apply openai-request path `(,@args
                               #:method POST
                               #:headers ((content-type application/json))
                               #:body ,body)))

;; TODO best practice boundary string
(define %boundary "========")

(define* (openai-post-multipart path params #:rest args)
  (apply openai-request path
         `(,@args
           #:method POST
           #:headers ((content-type multipart/form-data
                                    (boundary . ,%boundary)))
           #:body ,(multipart-params->tree params %boundary))))
